import sys
import os
import re
import xml.sax
import MySQLdb
import smtplib
import urllib.parse
import logging
from io import StringIO

import EJServiceConfig

##### Logging #####

logLevel = logging.WARNING
logFormatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")

logReport = StringIO()
strHandler = logging.StreamHandler(logReport)
strHandler.setLevel(logLevel)
strHandler.setFormatter(logFormatter)

consoleHandler = logging.StreamHandler()
consoleHandler.setLevel(logLevel)
consoleHandler.setFormatter(logFormatter)

rootLogger = logging.getLogger()
rootLogger.addHandler(strHandler)
rootLogger.addHandler(consoleHandler)
rootLogger.setLevel(logLevel)

##### SAX XML Handler #####

class XMLElement:
    def __init__(self, type):
        self.__type = type
        self.__children = []

    @property        
    def type(self):
        return self.__type

    @property
    def children(self):
        return self.__children

    @children.setter
    def children(self, val):
        self.__children = val

    def addChild(self, element):
        self.__children.append(element)

    def hasChildren(self):
        return len(self.__children) > 0

    def isTag(self):
        return self.__type == "Tag"

    def isVal(self):
        return self.__type == "Val"

class Tag(XMLElement):
    def __init__(self, tag):
        super().__init__("Tag")
        self.__tag = tag
        self.__attr = {}

    @property        
    def tag(self):
        return self.__tag

    @property        
    def attr(self):
        return self.__attr

    @attr.setter
    def attr(self, val):
        self.__attr = val

    def addAttr(self, name, val):
        self.__attr[name] = val

class Val(XMLElement):
    def __init__(self, val):
        super().__init__("Val")
        self.__val = val

    @property        
    def val(self):
        return self.__val

class XMLErrorHandler(xml.sax.ErrorHandler):
    def __init__(self):
        pass

    def error(self, exception):
        logging.error("SAX error: {0}".format(str(exception)))

    def fatalError(self, exception):
        logging.critical("SAX fatal: {0}".format(str(exception)))

    def warning(self, exception):
        logging.warning("SAX warning: {0}".format(str(exception)))

class XMLHandler(xml.sax.ContentHandler):
    def __init__(self):
        self.__stack = []
        self.__elements = []

    def getElements(self):
        return self.__elements
    
    def startElement(self, tag, attributes):
        element = Tag(tag)
        for name in attributes.getQNames():
            element.addAttr(name, attributes.getValueByQName(name))
        self.__push(element)

    def endElement(self, tag):
        if not self.__isStackAvailable():
            logging.warning("End tag does not matched in empty stack: {0}".format(tag))
            return

        element = self.__top()
        if not element.isTag():
            logging.warning("End tag ({0}) mismatch with type: {1}".format(tag, element.type))
            return

        if element.tag != tag:
            logging.warning("End tag ({0}) mismatch with tag: {1}".format(tag, element.tag))
            return

        self.__pop()
        if self.__isStackAvailable():
            self.__top().addChild(element)
        else:
            self.__elements.append(element)

    def characters(self, content):
        if self.__isEmptyContent(content):
            return
        
        if not self.__isStackAvailable():
            logging.warning("No tag available for value: {0}".format(content))
            return

        children = self.__top().children
        if len(children) > 0 and children[-1].isVal():
            # Combine to previous content
            children[-1] = Val(children[-1].val + content)
        else:
            self.__top().addChild(Val(content))
        
    def __isEmptyContent(self, content):
        isEmpty = True            
        for c in content:
            # If any is between visible ASCII codes
            if ord(c) > 32:
                isEmpty = False
                break
        return isEmpty

    def __push(self, element):
        self.__stack.append(element)

    def __pop(self):
        return self.__stack.pop()

    def __top(self):
        return self.__stack[-1]

    def __isStackAvailable(self):
        return len(self.__stack) > 0

##### Helper #####

def Usage():
    print("python NoteFormatter.py check {NOTE_ID | \\*} CHECK_DIR")
    print("    or")
    print("python NoteFormatter.py format {NOTE_ID | \\*} [MODIFY_DATE]")
    print("    or")
    print("python NoteFormatter.py compare XML_PATH CHECK_DIR")

##### XML Formatter #####

def EncodeXML(contents):
    # The escaped char needs to be encode, otherwise SAX could not handle it
    contents = contents.replace("&nbsp;", "sssspaceeee")
    contents = contents.replace("&amp;", "ppppoundddd")
    contents = contents.replace("&lt;", "llllttttlessssss")
    contents = contents.replace("&gt;", "ggggttttbigggg")
    contents = contents.replace("&quot;", "qqqquottttt")
    contents = contents.replace("&apos;", "aaaaposssss")
    contents = contents.replace("&times;", "ttttiiiimmmmeeeessss")
    contents = contents.replace("&divide;", "ddiivviiddeeee")
    return contents

def DecodeXML(contents):
    contents = contents.replace("sssspaceeee", "&nbsp;")
    contents = contents.replace("ppppoundddd", "&amp;")
    contents = contents.replace("llllttttlessssss", "&lt;")
    contents = contents.replace("ggggttttbigggg", "&gt;")
    contents = contents.replace("qqqquottttt", "&quot;")
    contents = contents.replace("aaaaposssss", "&apos;")
    contents = contents.replace("ttttiiiimmmmeeeessss", "&times;")
    contents = contents.replace("ddiivviiddeeee", "&divide;")
    return contents

def FixXMLSyntaxIssue(contents):
    # XML must be wrapped in one tag in SAX parser
    contents = "<xml>{0}</xml>".format(contents)

    # Remove <br>
    contents = contents.replace("<br>", "")
    # Remove <br ...>
    searchStartIndex = 0
    while True:
        indexBegin = contents.find("<br ", searchStartIndex)
        if indexBegin < 0:
            break
        indexEnd = contents.find(">", indexBegin)
        if indexEnd < 0 or indexEnd + 1 >= len(contents):
            logging.warning("Could not match <br ...>")
            break
        contents = contents[: indexBegin] + contents[indexEnd + 1 :]
        searchStartIndex = indexBegin

    # Remove "&nbsp;", it is white space but %20 is used in contents
    contents = contents.replace("&nbsp;", "")
    
    return EncodeXML(contents)

def FilterElements(elements):
    result = []
    for element in elements:
        if element.isVal():
            result.append(element)
        elif element.isTag():
            tag = FilterTag(element)
            if tag:
                result.append(tag)
        else:
            loging.warning("Not supported type in element filter")
    return result

def FilterTag(tag):
    if tag.tag not in ["font", "a", "p", "ol", "ul", "li", "blockquote", "i", "b", "span", "h1", "h2", "h3", "h4", "h5", "h6"]:
        logging.warning("Not handled tag: {0}".format(tag.tag))

    # Do not need empty <p></p>
    if tag.tag == "p":
        if not tag.hasChildren():
            logging.debug("Remove empty <p>")
            return None

    # Empty <a> will be removed
    if tag.tag == "a":
        if "href" not in tag.attr:
            logging.debug("Remove empty <a>")
            if tag.hasChildren():
                logging.warning("Try to delete <a> tag with children")
            return None

    # Remove <font>
    if tag.tag == "font":
        logging.debug("Remove <font>")
        if tag.hasChildren():
            logging.warning("Try to delete <font> tag with children")
        return None

    # Header does not need any attribute and any tag element
    if re.match("^h[1-6]", tag.tag):
        if len(tag.attr) > 0:
            logging.debug("Delete attributes for header: {0}".format(tag.tag))
            tag.attr = {}
        vals = []
        for val in tag.children:
            if val.isVal():
                vals.append(val)
            else:
                logging.warning("Delete header child tag element: {0}".format(val.tag))
        tag.children = vals

    # <p>, <ol>, <ul>, <li>, <blockquote>, <i>, <b>
    # also remove attributes
    noAttrTags = ["p", "ol", "ul", "li", "blockquote", "i", "b"]
    if tag.tag in noAttrTags:
        if len(tag.attr) > 0:
            logging.debug("Delete attributes for tag: {0}".format(tag.tag))
            tag.attr = {}

    # <span> attr with color will be kept
    if tag.tag == "span":
        copyAttr = {}
        for name in tag.attr:
            if name == "style" and tag.attr[name].find("color") >= 0 and tag.attr[name].find("color: rgb(0, 0, 0)") < 0:
                copyAttr[name] = tag.attr[name]
            else:
                logging.debug("Delete <span> attriute: {0}".format(name))
        tag.attr = copyAttr

    tag.children = FilterElements(tag.children)
    return tag

def ParseXML(contents):
    contents = FixXMLSyntaxIssue(contents)
    handler = XMLHandler()

    try:
        xml.sax.parseString(contents, handler, XMLErrorHandler())
    except Exception as e:
        logging.error("SAX parser fail: {0}".format(str(e)))
        return None

    # Remove the wrapper <xml> tag
    elements = handler.getElements()
    if len(elements) == 1 and elements[0].tag == "xml":
        elements = [element for element in elements[0].children]
    else:
        logging.warning("Parsed elements are not wrapped by <xml> tag")

    return elements

def OutputXML(output, elements):
    elements = FilterElements(elements)
    for element in elements:
        if element.isTag():
            attributes = "";
            for name in element.attr:
                attributes += " {0}=\"{1}\"".format(name, element.attr[name])
            output(DecodeXML("<{0}{1}>".format(element.tag, attributes)))
            OutputXML(output, element.children)
            output(DecodeXML("</{0}>".format(element.tag)))
        elif element.isVal():
            output(DecodeXML(element.val))
        else:
            logging.warning("Not supported type: {0}".format(element.type))

##### DB Handler #####

def DecodeNote(note):
    note = urllib.parse.unquote(note)
    note = note.replace("::;;", "&")
    note = note.replace(";;::", "#")
    return note

def EncodeNote(note):
    note = note.replace("&", "::;;")
    note = note.replace("#", ";;::")

    # The original html is encode by PHP and JS
    # Now in Python the char in safe string should be ignored
    note = urllib.parse.quote(note, safe = "=;',~!@$*()+:/?")

    return note

def ReadNoteInDB(id, date):
    notes = {}
    try:
        db = MySQLdb.connect(EJServiceConfig.ejDBServer, EJServiceConfig.ejDBUsername, EJServiceConfig.ejDBPassword, EJServiceConfig.ejDBDBName, charset = "utf8")
    except Exception as e:
        logging.error("DB operation failed for read: {0}".format(str(e)))
        return notes
    cursor = db.cursor()
    idCondition = " and ID={0}".format(id) if id.isdigit() else " and ID!=96"
    dateCondition = " and Modified>'{0}'".format(date) if date is not None else ""
    sql = "select ID, Note from note where FID=1 and Type=1{0}{1}".format(idCondition, dateCondition)
    logging.info("Select from DB: {0}".format(sql))
    cursor.execute(sql)
    notes = {note[0]: DecodeNote(note[1]) for note in  cursor.fetchall()}
    db.close()
    return notes

def WriteNoteToDB(id, note):
    try:
        db = MySQLdb.connect(EJServiceConfig.ejDBServer, EJServiceConfig.ejDBUsername, EJServiceConfig.ejDBPassword, EJServiceConfig.ejDBDBName, charset = "utf8")
    except Exception as e:
        logging.error("DB operation failed for update: {0}".format(str(e)))
        return
    cursor = db.cursor()
    sqlFormat = "update note set note=\"{0}\" where id={1} and fid=1".format("{0}", str(id))
    cursor.execute(sqlFormat.format(EncodeNote(note)))
    logging.info("Write to DB: {0}".format(sqlFormat.format("...")))
    db.commit()
    db.close()

##### Command #####

def CompareNote(xmlPath, checkDir):
    contents = ""
    with open(xmlPath, encoding = "utf-8") as xmlFile:
        for line in xmlFile.readlines():
            contents += line
    elements = ParseXML(contents)
    if elements is None:
        logging.error("Parse failed for compare, xml = {0}".format(xmlPath))
        return
    with open(os.path.join(checkDir, "format.html"), "w", encoding = "utf-8") as xmlFile:
        xmlFile.write("<html><body>")
        def writeToFile(text):
            nonlocal xmlFile
            xmlFile.write(text)
        OutputXML(writeToFile, elements)
        xmlFile.write("</body></html>")
    with open(os.path.join(checkDir, "origin.html"), "w", encoding = "utf-8") as xmlFile:
        xmlFile.write("<html><body>")
        xmlFile.write(contents)
        xmlFile.write("</body></html>")

def CheckNotes(notes, checkDir):
    for id in notes:
        logging.info("Check: {0}".format(id))
        elements = ParseXML(notes[id])
        if elements is None:
            logging.error("Parse failed for check, id = {0}".format(str(id)))
            continue
        path = os.path.join(checkDir, "{0}_format.html".format(str(id)))
        with open(path, "w", encoding = "utf-8") as xmlFile:
            xmlFile.write("<html><body>")
            def writeToFile(text):
                nonlocal xmlFile
                xmlFile.write(text)
            OutputXML(writeToFile, elements)
            xmlFile.write("</body></html>")
        path = os.path.join(checkDir, "{0}_origin.html".format(str(id)))
        with open(path, "w", encoding = "utf-8") as xmlFile:
            xmlFile.write("<html><body>")
            xmlFile.write(notes[id])
            xmlFile.write("</body></html>")

def MailFormatWarning(log):
    message = """From: From Person <{0}>
To: To Person <{1}>
MIME-Version: 1.0
Content-type: text/html
Subject: EJ Note Formatter Warnings

{2}
""".format(EJServiceConfig.ejMailSender, EJServiceConfig.ejMailReceiver, log)
    try:
        server = smtplib.SMTP_SSL(EJServiceConfig.ejMailHost, EJServiceConfig.ejMailPort)
        server.login(EJServiceConfig.ejMailUser, EJServiceConfig.ejMailPassword)
        server.sendmail(EJServiceConfig.ejMailSender, EJServiceConfig.ejMailReceiver, message)
        server.close()
    except:
        logging.error("Mail operation failed")

def FormatNote(notes):
    for id in notes:
        logging.info("Format: {0}".format(id))
        elements = ParseXML(notes[id])
        if elements is None:
            logging.error("Parse failed for format, id = {0}".format(str(id)))
            continue
        note = ""
        def writeToString(text):
            nonlocal note
            note += text
        OutputXML(writeToString, elements)
        WriteNoteToDB(id, note)

##### main #####

if __name__ == "__main__":
    if len(sys.argv) != 3 and len(sys.argv) != 4:
        Usage()
        sys.exit()
    cmd = sys.argv[1]
    if cmd != "format" and cmd != "check" and cmd != "compare":
        Usage()
        sys.exit()
    noteId = sys.argv[2]
    xmlPath = sys.argv[2]
    checkDir = None
    modifyDate = None
    if cmd == "compare":
        if not os.path.isfile(xmlPath):
            logging.error("XML file is not valid: {0}".format(xmlPath))
            sys.exit()
    else:
        if noteId != "*" and not noteId.isdigit():
            Usage()
            sys.exit()
    if cmd == "check" or cmd == "compare":
        if len(sys.argv) != 4:
            Usage()
            sys.exit()
        checkDir = sys.argv[3]
        if not os.path.isdir(checkDir):
            logging.error("Check dir does not exist: {0}".format(checkDir))
            sys.exit()
    if cmd == "format":
        if len(sys.argv) == 4:
            modifyDate = sys.argv[3]
            if not re.match("\\d\\d\\d\\d-\\d\\d-\\d\\d", modifyDate):
                logging.error("Date format should be: YYYY-MM-DD")
                sys.exit()

    if cmd == "check":
        logging.info("Check Notes, note id = {0}, check dir = {1}".format(str(noteId), checkDir))
        CheckNotes(ReadNoteInDB(noteId, modifyDate), checkDir)
    elif cmd == "format":
        logging.info("Format Notes, note id = {0}".format(str(noteId)))
        FormatNote(ReadNoteInDB(noteId, modifyDate))
        if modifyDate != None and len(logReport.getvalue()) > 0:
            MailFormatWarning(logReport.getvalue())
    elif cmd == "compare":
        logging.info("Compare Notes, original xml = {0}, check dir = {1}".format(xmlPath, checkDir))
        CompareNote(xmlPath, checkDir)
    else:
        logging.warning("Unknown command: {0}".format(cmd))
